iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

Kotlin Lambda 從零開始系列 第 27

Kotlin Lambda 從零開始 Day 27:從 Eager 到 Lazy — 為什麼需要 Sequence?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會說清楚 Collection 鏈式操作的中間集合問題,理解 Sequence 怎麼用 Lazy evaluation 避免不必要的計算和記憶體浪費

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
Sequence<T> IEnumerable<T> 都是 Lazy
asSequence() 不需要 (C# 預設就是 Lazy)
toList() ToList() 終端操作

C# LINQ 的 WhereSelect 等轉換運算子預設延遲執行,所以沒有 asSequence() 這個切換點。Kotlin Collection 的 filtermap 會立刻建立結果,要延遲處理才改用 Sequence

從 LINQ 轉過來,最容易搞混的地方

剛從 C# 轉到 Kotlin 時,看到 filtermap 長得很像,很自然會以為執行方式也一樣。差別要等到管線變長,或在 Lambda 裡加 log,才會浮出來

C# 的 WhereSelect 通常等到列舉才執行;ToList()Count()First() 則會取得結果。因此「LINQ 預設 Lazy」是個簡化的說法,延遲的是轉換運算子的那些,而取值的方法一樣是立刻執行

Kotlin 把兩種行為拆成不同型別。Collection 操作回傳新的集合,Sequence<T> 的中間操作建立延遲管線;asSequence() 就是兩者之間的切換點。這是從 API 就能直接確認的行為;至於當初為什麼這樣設計,官方文件沒有說明,這裡就不多做猜測

Java 的做法又不同:集合本身保留原有 API,另外用 Stream<T> 提供延遲管線與平行模式。stream() 的角色接近 Kotlin 的 asSequence();Kotlin Sequence 的介面則只有一個 iterator()

如果只記使用方式,可以先這樣分

  • C#:預設 Lazy,要立刻求值就 .ToList()
  • Java:Eager 集合 + Stream (提供延遲管線與平行模式,只能用一次)
  • Kotlin:Eager 集合 + Sequence (預設只做 Lazy,多數情況可重複遍歷)

問題在哪?

前面 day 05 到 day 26 手刻的 Collection 函式都是 Eager 的。看一段典型的鏈式操作

employees
    .filter { it.salary > 70000 }   // 建立 List A
    .map { it.name }                // 建立 List B
    .first()                        // 只需要第一個

filter 會先走完 7 個 employee,產出 4 人的 List A;map 再走完這 4 人,產出 List B。可是最後的 first() 只拿第一個名字,其餘計算與兩個中間 List 都沒有被後續使用

兩個中間 List,一個都不需要留下來。如果資料量是十萬筆,這個浪費就很明顯

用測試驗證浪費

用計數器追蹤每個步驟實際處理了多少元素

@Test
fun `eager processes all elements at each step`() {
    var filterCount = 0
    var mapCount = 0

    val result = employees
        .filter { filterCount++; it.salary > 70000 }
        .map { mapCount++; it.name }
        .first()

    assertEquals("Alice", result)
    assertEquals(7, filterCount)  // filter 走完全部 7 個
    assertEquals(4, mapCount)     // map 走完 filter 結果的 4 個
}

filterCount 是 7,表示 filter 走了全部 7 個員工。mapCount 是 4,表示 map 走了 filter 結果的 4 個人。總共處理了 11 次,但只需要 2 次 (Alice 通過 filter 一次、map 一次) 就能拿到答案

Sequence 怎麼解決

加一個 asSequence() 就好,後面其餘程式碼一字不改

@Test
fun `lazy processes elements one by one`() {
    var filterCount = 0
    var mapCount = 0

    val result = employees.asSequence()
        .filter { filterCount++; it.salary > 70000 }
        .map { mapCount++; it.name }
        .first()

    assertEquals("Alice", result)
    assertEquals(1, filterCount)  // 第一個就通過 filter,停了
    assertEquals(1, mapCount)     // 只 map 一個
}

filterCountmapCount 都是 1。Sequence 拿到 Alice 後就停止,不會再處理後面六位員工

處理順序的差異

Eager 和 Lazy 的處理順序完全不同

Eager:逐步驟

Step 1 — filter 全跑完
  Alice(85K) ✓  Bob(72K) ✓  Charlie(65K) ✗  Diana(78K) ✓
  Eve(60K) ✗  Frank(55K) ✗  Grace(92K) ✓
  → 產出 List A: [Alice, Bob, Diana, Grace]

Step 2 — map 全跑完
  Alice → "Alice"  Bob → "Bob"  Diana → "Diana"  Grace → "Grace"
  → 產出 List B: ["Alice", "Bob", "Diana", "Grace"]

Step 3 — first
  → 取第一個 "Alice"

Eager 是「一個步驟跑完所有元素,再進下一個步驟」。像工廠流水線,每個工站處理完所有零件,裝箱送到下一站

Lazy:逐元素

元素 1 — Alice(85K)
  filter: 85000 > 70000 → ✓ 通過
  map: Alice → "Alice"
  first: 拿到了,結束!

一個元素就搞定了。Bob、Charlie 他們根本不用處理

Lazy 是「一個元素走完整條 pipeline,再處理下一個元素」。如果中途拿到答案,後面的元素不會被碰到

Debug 時的呼叫順序是反的

在 debugger 裡單步走 Sequence,斷點的順序跟程式碼寫的順序是反的:不是從 filter 開始,而是先進 toList(),再跳到 map,最後才進 filter。寫過 LINQ 的人對這個順序應該不陌生,Where().Select().ToList() 在 C# 也是這樣反著跑

原因是這條鏈建立出來的物件是由外往內包的

TransformingSequence (map)
  └─ FilteringSequence (filter)
       └─ 原始 List 的 iterator

toList() 只認得最外層的 map,所以跟它要元素時,呼叫方向只能一路往上游傳

toList()                    ← 終端操作啟動
  → map 的 next()           ← 我沒有,跟上游要
    → filter 的 hasNext()   ← 我也沒有,再跟上游要
      → 原始 iterator       ← Alice
    ← predicate 判斷 Alice 通過
  ← transform 產出 "Alice"
← "Alice" 放進 List

控制流由下往上 (誰跟誰要資料),資料流由上往下 (結果怎麼回傳)。LINQ 的 deferred execution 也是靠巢狀的 enumerator 做的,所以兩邊看到的順序才會一致

有兩點跟 C# 不太一樣。第一,Kotlin 只有 Sequence 是這樣,前面沒加 asSequence() 的 Collection 版本,debug 順序就是老實的由上往下:filter 跑完 7 個、map 跑完 4 個、才進 first()。第二,C# 的 WhereSelect 是編譯器產生的 yield return 狀態機,predicate 跑在 MoveNext() 裡;Kotlin 的 filter 則把判斷放在 hasNext()FilteringSequence 的 iterator 會在那裡先算好下一個通過的元素,map 的 transform 才在 next() 執行。所以斷點會停在 FilteringSequence$iterator$1.hasNext() 這種地方,行為結果相同,只是 call stack 的長相不同

與 stdlib 原始碼比較

原始碼位置:kotlin.sequencesSequence.kt

這篇還沒開始手刻函式,但可以先看 Kotlin stdlib 裡 Sequence 介面的真實長相

public interface Sequence<out T> {
    public operator fun iterator(): Iterator<T>
}

只有一個方法。跟 C# 的 IEnumerable<T> 一樣,只要求提供一個 Iterator。out T 是型別協變,表示 Sequence<Dog> 可以當成 Sequence<Animal>

對照 stdlib 的設計可以看出兩件事。第一,介面本身極簡,所有 Lazy 行為都藏在 Iterator 的實作裡,不在介面上。第二,stdlib 的 filtermap 這些中間操作其實是 Sequence<T> 的擴充函式,各自回傳一個包了上一層的新 Sequence,這就是下一篇開始要動手刻的東西

Lazy 的關鍵:中間操作不執行

@Test
fun `sequence is lazy until terminal operation`() {
    var executed = false

    val seq = listOf(1, 2, 3).asSequence()
        .map { executed = true; it * 2 }

    assertFalse(executed)  // 還沒執行

    val result = seq.toList()  // 終端操作觸發執行
    assertTrue(executed)
    assertEquals(listOf(2, 4, 6), result)
}

asSequence().map { ... } 之後,Lambda 完全沒有被呼叫。executed 還是 false。要到 toList() 這個終端操作出現,整條 pipeline 才會啟動

Sequence 的操作分兩種

  • 中間操作 (intermediate):filtermaptake 等,回傳新的 Sequence,不執行任何計算
  • 終端操作 (terminal):toList()first()count() 等,觸發整條 pipeline,產出最終結果

這跟 C# 的 LINQ 一模一樣。WhereSelect 是中間操作,ToList()First() 是終端操作。這兩種操作的判斷規則 day 30 會深入講,這裡先知道「中間不執行、終端才觸發」就夠了

小結

在這篇的 filter → map → first 範例裡,Collection 先建立兩個中間 List,Sequence 則一路處理到拿到答案為止。這不代表看到大集合就該一律加上 asSequence();先看管線能不能短路,以及最後是否仍要完整物化,判斷會比較準

至於實務上該怎麼在兩者之間選,day 31 會用 JMH 實測的耗時和記憶體配置量,給一份完整的判斷準則

下一篇開始手刻 Sequence 的基礎設施,自己實作 mySequenceOfmyEmptySequencemyAsSequencemyGenerateSequence

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 26:union / intersect / subtract / plus / minus — 集合運算
下一篇
Kotlin Lambda 從零開始 Day 28:手刻 Sequence 基礎設施
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言